今天我們來談談 SOLID 原則中的第三個原則 —— 里氏替換原則(Liskov Substitution Principle, LSP)。這個原則由麻省理工學院的 Barbara Liskov 教授在 1987 年提出,是物件導向設計中非常重要的概念。
里氏替換原則的核心思想是:
任何基類別可以出現的地方,子類別一定可以出現,而且替換後程式的行為不應該發生變化
換句話說,子類別必須能夠完美替換其父類別,而不破壞程式的正確性。這個原則確保了繼承關係的合理性,也是多態性能夠正常運作的基礎。
值得一提的是,違反里氏替換原則的設計往往會導致程式在執行時產生意外的行為,特別是當我們使用多態性時。這種問題在編譯時期通常不會被發現,只有在執行時期才會暴露出來。
接下來我們會透過一個經典的「鳥類飛行」例子來了解這個原則,並學習如何避免常見的設計陷阱。
// 不好的範例:違反 LSP 的鳥類設計
abstract class Bird {
abstract fly(): string;
abstract makeSound(): string;
// 所有鳥類的共同行為
eat(): string {
return "正在進食...";
}
}
// 一般會飛的鳥
class Sparrow extends Bird {
fly(): string {
return "麻雀正在飛翔中...";
}
makeSound(): string {
return "嘰嘰喳喳";
}
}
class Eagle extends Bird {
fly(): string {
return "老鷹正在高空翱翔...";
}
makeSound(): string {
return "啾啾";
}
}
// 問題來了:企鵝不會飛!
class Penguin extends Bird {
fly(): string {
// 企鵝不會飛,但被迫要實作 fly 方法
// 這違反了里氏替換原則!
throw new Error("企鵝不會飛!");
}
makeSound(): string {
return "嘎嘎";
}
// 企鵝特有的行為
swim(): string {
return "企鵝正在游泳...";
}
}
// 鳥類管理系統
class BirdManager {
makeBirdsFly(birds: Bird[]): string[] {
const results: string[] = [];
for (const bird of birds) {
try {
// 這裡假設所有 Bird 都能飛
// 但當 bird 是 Penguin 時就會拋出例外!
results.push(bird.fly());
} catch (error) {
results.push(`錯誤:${error.message}`);
}
}
return results;
}
}
// 使用範例 - 會產生問題
const birdManager = new BirdManager();
const birds: Bird[] = [
new Sparrow(),
new Eagle(),
new Penguin() // 這隻企鵝會破壞程式的正常運作
];
const flyingResults = birdManager.makeBirdsFly(birds);
console.log(flyingResults);
// 輸出:
// ["麻雀正在飛翔中...", "老鷹正在高空翱翔...", "錯誤:企鵝不會飛!"]
上面的設計違反了里氏替換原則,主要問題包括:
Penguin 的 fly() 方法拋出例外,與其他 Bird 子類別的行為完全不同Bird 陣列時,無法安全地呼叫 fly() 方法fly() 方法,但這並不符合其天性// 修正範例:符合 LSP 的鳥類設計
abstract class Bird {
abstract makeSound(): string;
eat(): string {
return "正在進食...";
}
}
// 會飛的鳥類介面
interface Flyable {
fly(): string;
getMaxAltitude(): number;
}
// 會游泳的鳥類介面
interface Swimmable {
swim(): string;
getMaxDepth(): number;
}
// 會飛的鳥類
class FlyingBird extends Bird implements Flyable {
constructor(protected species: string) {
super();
}
fly(): string {
return `${this.species}正在飛翔中...`;
}
getMaxAltitude(): number {
return 1000; // 預設最大飛行高度
}
makeSound(): string {
return "鳥鳴聲";
}
}
// 不會飛但會游泳的鳥類
class SwimmingBird extends Bird implements Swimmable {
constructor(protected species: string) {
super();
}
swim(): string {
return `${this.species}正在游泳...`;
}
getMaxDepth(): number {
return 50; // 預設最大游泳深度
}
makeSound(): string {
return "鳥鳴聲";
}
}
// 具體的鳥類實作
class Sparrow extends FlyingBird {
constructor() {
super("麻雀");
}
makeSound(): string {
return "嘰嘰喳喳";
}
getMaxAltitude(): number {
return 500; // 麻雀飛行高度較低
}
}
class Eagle extends FlyingBird {
constructor() {
super("老鷹");
}
makeSound(): string {
return "啾啾";
}
getMaxAltitude(): number {
return 3000; // 老鷹可以飛得很高
}
}
class Penguin extends SwimmingBird {
constructor() {
super("企鵝");
}
makeSound(): string {
return "嘎嘎";
}
getMaxDepth(): number {
return 200; // 企鵝可以潛水很深
}
}
// 改進後的鳥類管理系統
class ImprovedBirdManager {
// 讓會飛的鳥飛翔
makeFlyingBirdsFly(flyingBirds: Flyable[]): string[] {
return flyingBirds.map(bird => {
const flyMessage = bird.fly();
const altitude = bird.getMaxAltitude();
return `${flyMessage} (最大飛行高度:${altitude}m)`;
});
}
// 讓會游泳的鳥游泳
makeSwimmingBirdsSwim(swimmingBirds: Swimmable[]): string[] {
return swimmingBirds.map(bird => {
const swimMessage = bird.swim();
const depth = bird.getMaxDepth();
return `${swimMessage} (最大潛水深度:${depth}m)`;
});
}
// 讓所有鳥類發出聲音
makeBirdsSound(birds: Bird[]): string[] {
return birds.map(bird => {
const sound = bird.makeSound();
return `鳥類發出聲音:${sound}`;
});
}
}
// 使用範例 - 現在可以安全運作了
const improvedManager = new ImprovedBirdManager();
const sparrow = new Sparrow();
const eagle = new Eagle();
const penguin = new Penguin();
// 所有鳥類都可以發出聲音,符合 LSP
const allBirds: Bird[] = [sparrow, eagle, penguin];
const soundResults = improvedManager.makeBirdsSound(allBirds);
console.log("所有鳥類的聲音:");
soundResults.forEach(result => console.log(result));
// 只有會飛的鳥才會被要求飛翔
const flyingBirds: Flyable[] = [sparrow, eagle];
const flyingResults = improvedManager.makeFlyingBirdsFly(flyingBirds);
console.log("\n會飛的鳥類:");
flyingResults.forEach(result => console.log(result));
// 只有會游泳的鳥才會被要求游泳
const swimmingBirds: Swimmable[] = [penguin];
const swimmingResults = improvedManager.makeSwimmingBirdsSwim(swimmingBirds);
console.log("\n會游泳的鳥類:");
swimmingResults.forEach(result => console.log(result));
FlyingBird 和 SwimmingBird,避免強制所有鳥類都要實作飛行能力Flyable 和 Swimmable 介面來定義特定能力,遵循介面隔離原則這時候我們可以發現,改進後的設計讓每個類別都能完美替換其父類別,而且不會產生執行時期的錯誤。這就是里氏替換原則的威力!
里氏替換原則的核心概念提醒我們:
需要注意的是,里氏替換原則不僅關乎技術實作,更關乎設計的合理性。當我們發現子類別需要拋出例外或是無法實作某些方法時,通常表示繼承結構需要重新思考。
值得一提的是,違反 LSP 的設計往往也會違反開放封閉原則,因為我們可能需要在使用子類別的地方加入額外的條件判斷。透過遵循 LSP,我們可以建立更穩固的多態系統。
綜合以上所述,我們成功透過重新設計繼承結構實踐了里氏替換原則,讓系統中的每個子類別都能安全地替換其父類別。